YownYang's blog

译《Effective Objective-C 2.0》第一章

这是翻译《Effective Objective-C 2.0》的第一章:对Objective-C的认识

简介

Objective-C通过一种完整的新的写法为C语言带来了面向对象的功能。由于Objective-C使用大量的方括号和长的方法名,常被认为是繁琐的、冗长的。它生成的源代码非常易读但是不同于C++或Java的主流开发。

书写Objective-C代码可以让你快速的学习它但是经常会有许多细节和功能被忽视。类似的,一些功能在尚未完全理解的情况下被滥用,由此写出的代码是难以维护和DEBUG的。本章节讲解Objective-C的基本部分;后续章节讲解关于语言的特定领域和相关的框架。

了解Objective-C的本源

Objective-C同其他面向对象的语言是相似的,例如C++和Java,但是也有许多不同的地方。如果你有别的面向对象语言的经验,你将会理解它许多示例和使用的模式。然而,它的语法仍可能是陌生的因为它使用消息机制而不是函数调用。Objective-C源于Smalltalk,Smalltalk源于消息机制。消息机制与函数调用的不同看起来是这样的:

1
2
3
4
5
6
7
// Messaging (Objective-C)
Object *obj = [Object new];
[obj performWith:parameter1 and:parameter2];
// Function calling (C++)
Object *obj = new Object;
obj->perform(parameter1, parameter2);

两者的区别在于在消息机制中,是在运行时决定code的行为。而在函数调用中,是在编译时决定code的行为。当多态被引入到函数调用中时,查找它的方式是在运行时通过一个已知的虚拟表中查找。但是在消息机制中,一直是在运行时查找。事实上,编译器根本不在乎接收的对象类型。它也是在运行时查找,通过动态绑定确定类型,在第11节会有更详细的介绍。

Objective-C在运行时做了更多的工作而不是编译时。runtime包含了所有的数据结构和函数,它确保了Objective-C面向对象功能的正常使用。例如,runtime包含所有内存管理的方法。本质上,runtime是代码的集合,连接你所有代码和你以动态库方式引用的代码。因此,每当runtime更新时,你的应用将会享受到性能提升带来的收益。一门在编译时做更多工作的语言需要重新编译才能受益于性能的提升。

Objective-C是C的超集,当你写Objective-C代码时,C的所有特性也是可以使用的。因此,书写Objective-C代码实际上需要你理解OC和C的核心概念。尤其是理解C的内存模式将会帮助你理解Objective-C的内存模式以及引用计数的工作原理。这需要理解在Objective-C中一个指针是被用来代表一个对象。当你声明一个变量时,将会持有一个对象的引用,语法是这样的:

1
NSString *someString = @"The string";

这种语法大多来源于C,声明一个叫做someString的变量,类型是NSString *。它的意思是这是一个NSString类型的指针。所有的Objective-C对象都必须通过这种方式声明,因为对象的初始化一直在堆上并且绝不会再栈上。像下面这种声明是不合法的:

1
2
NSString stackString;
// error:interface type cannot be statically allocated

someString变量指向某个内存地址,在堆上初始化,包含一个NSString对象。这个意思是创建另一个变量指向同样的内存地址,不是copy,而是产生两个变量指向同一个对象:

1
2
NSString *someString = @"The string";
NSString *anotherString = someString;

Figure 1.1 内存布局展示了一个在堆上初始化的NSString实例和两个栈上初始化的变量指向它

这仅有一个NSString实例,但有两个变量指向同它。这两个变量的类型是NSString *,意思是当前栈上已经初始化了2bit大小的指针(32位下每个指针占4个字节,64位下每个指针占8个字节)。这2bit内存保存了同样的值:NSString实例的内存地址。

图1.1说明了这个结构。NSString实例存储了表示实际字符串所需要的字节。

在堆上分配的内存需要程序员管理,而栈上分配的内存是系统管理的,在它们所在的栈弹出时自动清理。

Objective-C堆上的内存管理是已经抽象的。你不需要使用mallocfree去初始化和释放对象内存。Objective-C的runtime通过一种被称为引用计数的管理机制抽象了它(具体看第29节)。

有时在Objective-C中你会遇到这种不带有*的声明并且使用栈控件的变量。这些变量不持有Objective-C对象。例如CGRect,来源于CoreGraphics框架:

1
2
3
4
5
CGRect frame;
frame.origin.x = 0.0f;
frame.origin.y = 10.0f;
frame.size.width = 100.0f;
frame.siez.height = 150.0f;

CGRect是一个C的结构体,定义是这样的:

1
2
3
4
5
struct CGRect {
CGRect origin;
CGSize size;
};
typedef struct CGRect CGRect;

这些类型的结构体被用于整个系统框架,在其中使用Objective-C对象可能会影响性能。创建对象会产生额外开销,而结构体不会,例如初始化和释放堆内存。当保存的数据类型不是对象时,通常会使用一个结构体,如CGRect

在开始书写Objective-C之前,我建议你去读C语言的文档并且熟悉它的语法。如果你直接书写Objective-C代码,你可能会找到部分令你困惑的语法。

小结

  • Objective-C是C的超集,添加了面向对象的功能。
  • Objective-C使用消息机制和动态绑定,意思是一个对象的类型是在运行时确定的。
  • Objective-C是运行时而不是编译时,通过消息决定代码如何运行。
  • 了解C语言的核心概念将帮助写出更有效的Objective-C代码。特别是你需要理解内存模式和指针。

减少在头文件中使用import

Objective-C使用头文件和实现文件就像C和C++一样。当在Objective-C写一个类,标准方法是创建的每个文件名均以类名命名,后缀带有.h的是头文件,带有.m的是实现文件。当你创建一个类时,它看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// EOCPerson.h
#import <Foundation/Foundation.h>
@interfrace EOCPerson : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@end
// EOCPerson.m
#import "EOCPerson.h"
@implementation EOCPerson
// Implementation of methods
@end

对所有类来说,导入Foundation.h是必须的,你将在Objective-C中一直使用它。或者你在某个类的父类中导入框架的头文件。例如,你创建一个iOS应用,你通常会创建UIViewController的子类。这些类的头文件将会导入UIKit.h

目前来讲,这个类的写法是没问题的。它导入了整个Foundation框架,但是并不需要在意。EOCPerson类继承自Foundation框架中的某一个类,它将会使用框架的一大部分功能。继承自UIViewController的类也是一样,它将会使用UIKit框架的一大部分功能。

随着时间的推移,你可能创建了一叫做EOCEmployer的新类。然后你决定一个EOCPerson实例持有一个EOCEmployer实例。所以你提前给它加了一个属性:

1
2
3
4
5
6
7
8
9
10
// EOCPerson.h
#import <Foundation/Foundation.h>
@interfrace EOCPerson : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property (nonatomic, copy) EOCEmployer *employer;
@end

一个问题出现了,当你编译时发现EOCEmployer是缺失的。这个时候编译器一定会提示你在EOCPerson.h中导入EOCEmployer.h。通常你是在EOCPerson.h顶部加入导入的方法:

1
#import "EOCEmployer.h"

这将使他正常编译,但这是一个坏的习惯。因为编译EOCPerson不需要知道EOCEmployer的详细信息。仅需要知道有一个叫做EOCEmployer的类存在即可。幸运的是,有一个办法可以告诉编译器这样:

1
@class EOCEmployer;

这叫做向前声明这个类。这样EOCPerson的头文件看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
// EOCPerson.h
#import <Foundation/Foundation.h>
@class EOCEmployer;
@interfrace EOCPerson : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property (nonatomic, copy) EOCEmployer *employer;
@end

当你为了在实现文件中使用它时,你需要知道EOCEmployer的全部信息,你可以在实现文件导入它。所以实现文件看起来是这样的:

1
2
3
4
5
6
7
// EOCPerson.m
#import "EOCPerson.h"
#import "EOCEmployer.h"
@implementation EOCPerson
// Implementation of methods
@end

推迟导入是必要的,在需要的时候导入,可以使你限制它的作用域。在例子中,如果EOCEmployer.h是在EOCPerson.h中导入,你最后会有许多重复导入,那毫无疑问是会增加编译时间的。

使用前向声明会缓解两个类互相引用的问题。考虑下当EOCEmployer有两个方法去添加和移除EOCPerson的实例会发生什么,在头文件像这样定义:

1
2
- (void)addEmployee:(EOCPerson *)person;
- (void)removeEmployee:(EOCPerson *)person;

这时,在相反的情况下处于同样的原因,EOCPerson类需要对编译器可见。可是,通过在别的每个头文件导入它去实现会产生一个”先有鸡还是先有蛋”的问题。当一个头文件被解析时,它导入了别的头文件,而别的头文件也导入了它,那么哪个是第一个导入呢。使用#import而不是#include可以避免这个问题,但是其中有一个类会不能正确编译。如果你不相信我就自己试试喽。

有时,你需要在一个头文件导入另一个头文件。你一定需要导入你所继承类的头文件。类似的,如果你有任何协议需要去实现它,你将不得不使用完整的定义并且不能使用向前声明。编译器需要知道这个协议的所有定义而不是通过向前声明确定协议的存在。

例如,假设一个矩形类继承自一个形状类并要实现一个协议用于绘画:

1
2
3
4
5
6
7
8
9
10
// EOCRectangle.h
#import "EOCShape.h"
#import "EOCDrawable.h"
@interface EOCRectangle : EOCShape <EOCDrawable>
@property (nonatomic, assign) float width;
@property (nonatomic, assign) float height;
@end

这个导入是无法避免的。对于这样的协议,放置它们在自己类的头文件应该是谨慎的。如果EOCDrawable协议是一个大的头文件的一部分,你将不得不导入它的所有内容。如前面所描述的一样会产生同样的依赖和额外的编译时间的问题。

即使如此,也不是所有协议都是这样的。例如,代理协议(看第23节),需要放置在自己头文件中。在这种情况下,协议的使用场景仅在当它作为委托类的一部分一起定义时。在这种情况下,它最好声明在你的实现文件中,即.m中使用类扩展声明它。这意思是在实现文件中导入包含协议的头文件而不是在头文件中。

每当在头文件导入文件时,总是问自己这是否是必要的。如果导入可以用向前声明代替,那么使用向前声明。如果导入它为了使用一些属性,实例变量或者实现协议并且可以移动到实现文件中时,那么移动它。那将会尽可能减少编译时间和相互依赖的可能性,可以修复问题或者在公共API中减少你暴漏的代码。

小结

  • 总在尽可能深的层次导入头文件。经常在头文件使用向前声明并且在实现文件导入它们。这样做可以尽量避免两个类的相互引用。
  • 有时,向前声明是不适用的,在声明协议遵循时。在这种情况下,考虑移动协议遵循到类的实现文件中。或者,导入仅有协议定义的头文件。

多使用Literal Syntax少使用与之等价的方法


译者言:有人将Literal Syntax称为字面量语法,Literal Number称为字面量数字,Literal Array称为字面量数组,Literal Dictionary称为字面量字典。


当使用Objective-C时,你总会遇到几个类。这几个类是基础框架的一部分。从技术上讲,你不需要使用Foundation去书写Objective-C代码,你通常在练习中使用它们。这些类是NSString、NSNumber、NSArray、NSDictionary。它们的数据结构即是它们自身所代表的意思。

众所周知Objective-C拥有冗长的语法。这是真的。然而,自从Objective-C 1.0开始,有一个非常简单的办法去创建一个NSString对象。它被称为String Literal并且看起来像这样:

1
NSString *someString = @"Effective Objective-C 2.0";

这种类型的语法是不存在的,通常创建一个NSString对象是需要调用alloc方法后,调用init方法的。幸运的是,这种被称作Literal Syntax,在最近的编译器版本中已经支持的了。同样也包括NSNumber、NSArray、NSDictionary的实例。使用Literal Syntax减少了代码大小,并且使代码更易读。

Literal Numbers

有时,你需要在一个对象中包含一个整数,或者浮点数,或者布尔值。你可以通过使用NSNumber实现它,它可以处理一系列的数字类型。使用Literal Number之前,你创建实例时是这样的:

1
NSNumber *someNumber = [NSNumber numberWithInt:1];

它创建了一个数值,并将值设为1。然而,使用Literal Number使它更简单:

1
NSNumber *someNumber = @1;

如你所见,Literal Number是更简洁的。然而好处远远不止这些。这种语法包含所有NSNumber实例可以代表的数据类型。例如:

1
2
3
4
5
NSNumber *intNumber = @1;
NSNumber *floatNumber = @2.5f;
NSNumber *doubleNumber = @3.14159;
NSNumber *boolNumber = @YES;
NSNumber *charNumber = @'a';

这种Literal Syntax也适用于表达式:

1
2
3
int x = 5;
float y = 6.32f;
NSNumber *expressionNumber = @(x * y);

使用Literal Syntax对于数值来说是非常有用的。这样做可以使NSNumber对象更简洁明了,因为声明的大部分是值而不是多余的语法。

Literal Arrays

数组是一个常用的数据结构。使用Literal Syntax之前,你是这样创建一个数组的:

1
NSArray *animals = [NSArray arrayWithObjects:@"cat", @"dog", @"mouse", @"badger", nil];

使用Literal Syntax之后,仅需要使用下面的语法:

1
NSArray *animals = @[@"cat", @"dog", @"mouse", @"badger"];

这已经是一种很简单的语法了,但是它对数组的好处远不止于此。一个常见的操作是根据一个确定的下标从数组中取值。使用Literal Array这也是简单的。通常你会使用objectAtIndex: method::

1
NSString *dog = [animals objectAtIndex:1];

而使用Literal Syntax,只需要像下面的做法一样:

1
NSString *dog = animals[1];

这称作下标取值,就像其他的Literal Syntax一样,它更简洁明了的指出它做了什么。此外,它看起来与别的语言的取值方法非常相似。

然而,当你使用Literal Syntax创建一个数组时你需要知道一件事情。如果任何的对象为空,会抛出一个异常,因为Literal Syntax仅仅是创建一个数组然后添加方括号中所有元素的语法糖。这个异常看起来是这样的:

1
2
3
4
*** Terminating app due to uncaught exception
'NSInvalidArgumentException', reason: '***
-[__NSPlaceholderArray initWithObjects:count:]: attempt to
insert nil object from objects[0]'

这将导致当使用Literal Syntax时会产生一个常见的问题。下面的代码创建了两个数组,每一个语法如下:

1
2
3
4
5
6
id object1 = /*...*/;
id object2 = /*...*/;
id object3 = /*...*/;
NSArray *arrayA = [NSArray arrayWithObjects:object1, object2, object3, nil];
NSArray *arrayB = @[object1, object2, object3];

现在考虑这样一个场景,当object1object3指向一个有效的Objective-C对象,但是object2是空的。这literal array,arrayB,将会抛出一个异常。然而,arrayA 仍将会被创建但只包含object1对象。原因是arrayWithObjects:方法添加参数,遇到nil终止,这比预想的结束的早。

这种微小的不同意味着Literal Synta更加安全。抛出一个异常,可能导致程序结束是更好的,而不是创建一个比预想中元素要少的数组。程序员最可能的错误是往数组中插入一个空的对象,并且异常意味着更容易被发现。

Literal Dictionaries

字典提供一个map数据结构在其中添加键值对。类似于数组,字典也是Objective-C代码中常用的。创建一个使用是这样的:

1
2
3
4
5
NSDictionary *personData = [NSDictionary dictionaryWithObjectsAndKeys:
@"Matt", @"firstName",
@"Galloway", @"lastName",
[NSNumber numberWithInt:28], @"age",
nil];

这令人相当困惑,因为这顺序是object, key, object, key, …。然而,你通常认为字典应当是key对应value。因此,它读起来不是很好理解。然而,Literal Syntax再一次令语法变得清楚:

1
2
3
4
NSDictionary *personData =
@{@"firstName" : @"Matt",
@"lastName" : @"Galloway",
@"age" : @28};

这样写是更简洁的,并且key在value之前,正是你所期望的。也要注意在示例中,Literal Numbers也是适用的。字典的value和key必须是Objective-C对象,所以你不能直接使用整数18区存储,相应的,你必须将它包含在一个NSNumber实例中。但是Literal Syntax意味着它只是一个额外的字符。

就像数组一样,如果某个value为空,Literal Syntax会抛出异常。然而,由于同样的理由,这是一个好事。由于dictionaryWithObjectsAndKeys:方法在第一个value为空处结束,这意味着可能会创建出一个缺失value的字典,而不是抛出一个异常。

另一个类似数组的地方,字典也可以通过Literal Syntax进行值的存取。旧的存取一个值的方法需要义哥确定的key如下面这样:

1
NSString *lastName = [personData objectForKey:@"lastName"];

与之等价的Literal Syntax是这样的:

1
NSString *lastName = personData[@"lastName"];

再一次,Literal Syntax减少了复杂的代码,留下了易读的代码。

Mutable Arrays and Dictionaries

以同样的方法,你可以通过下标去访问数组元素或者通过key访问字典元素。如果它们是可变的,你还可以设置它们。通过正常方法设置可变数组或可变字典是这样的:

1
2
[mutableArray replaceObjectAtIndex:1 withObject:@"dog"];
[mutableDictionary setObject:@"Galloway" forKey:@"lastName"];

通过Literal Syntax设置时这样的:

1
2
mutableArray[1] = @"dog";
mutableDictionary[@"lastName"] = @"Galloway";

Limitations

使用Literal Syntax有一个小限制,除了字符串外,其余创建对象的类必须是基础框架中的一个。没有办法指定你自己创建的子类替代它的创建。如果你想使用自定义的子类创建实例,那么你不能使用Literal Syntax。然而,由于NSArray、NSNumber、NSDictionary是类簇(看第9节),它们很少被继承,因为这样做意义不大。此外,标准的实现通常是足够好的。字符串可以使用自定义的子类,但是它必须通过编译器去设置。除非你知道你想做什么,否则你是不会想去设置它的,你将会希望一直使用NSString类。

同样的,在这种情况下,字符串、数组、字典,仅有它们的可变类可以通过Literal Syntax创建对象。如果需要一个可变变量,mutableCopy必须被调用,像这样:

1
NSMutableArray *mutable = [@[@1, @2, @3, @4, @5] mutableCopy];

它添加了一个额外方法的调用,并且一个额外的对象将会被创建,但是使用Literal Syntax的好处是超过它的坏处的。

小结

  • 使用Literal Syntax去创建字符串,数字,数组,字典。它是比正常的创建对象的语法简洁和清晰地。
  • 通过下标法访问数组或者字典。
  • 使用Literal Syntax给数组或者字典插入一个空的值将会产生一个异常。因此,尽量确定它们的值不为空。

优先使用类型常量,减少使用#define预处理

在写代码时,你经常想去定义一个常量。例如,一个UIView类出现和消失时它自身的动画。你可能想定义一个常量来代表动画持续的时间。你已经学会了Objective-CC的基础,所以你决定使用的方法是这样的:

1
#define ANIMATION_DURATION 0.3

这是一个预处理指定;每当在你的代码中出现ANIMATION_DURATION字符,就用0.3替代。这看起来就是你想要的,但是这种定义没有类型信息。它就像是声明了一个叫做“duration”的事物意指它的value与时间有关系,但它并不是明确的。并且,它会将所有ANIMATION_DURATION替换掉,如果它声明在头文件中,别的任何导入了这个头文件的类都将被替代。

为了解决这个问题,你应该使用编译器。有一个比使用预处理更好的办法,去定义一个常量。例如,下面这种定义常量类型为NSTimeInterval

1
static const NSTimeInterval kAnimationDuration = 0.3;

注意这种格式,它是有类型信息的,这种用法是好的,因为我们可以清楚地知道常量的定义。它的类型是NSTimeInterval,并且它有助于指出变量的使用。如果你定义了许多常量,它将会帮助以后阅读代码的人。

也会指出这个变量如何命名的。通常的惯例是在常量前面加上小写字母k(加k的原因据说是constant的首字母读音,或者德语一般写作konstant)并将其放置在实现文件中。对于需要暴露给其它类的常量,通常使用其类名作为前缀。第19节展示了更多的命名标准。

在哪里定义常量是重要的。有时,使用预处理定义常量是诱人的,但这是一个坏的习惯,特别是命名方式没有遵循标准的时候,它们不会冲突。例如:ANIMATION_DURATION常量定义在头文件是一个坏的命名。它将显示在所有导入了这个头文件的文件中。甚至作为标准的static const也不该出现在头文件中。自从Objective-C不使用命名空间(namespaces),上面的代码将会声明一个叫做kAnimationDuration的全局变量。它的名字前缀应该使用其所作用的类的名字,例如EOCViewClassAnimationDuration。第19节展示了更多的关于使用清晰命名的方案。

一个不需要暴露给外界的常量,应该在需要使用它的实现文件中定义。例如,如果那个动画持续时间的常量被用在一个UIView的子类,子类在一个iOS应用中,它看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//EOCAnimatedView.h
#import <UIKit/UIKit.h>
@interface EOCAnimatedView : UIView
- (void)animate;
@end
//EOCAnimatedView.m
#import "EOCAnimatedView.h"
static const NSTimeInterval kAnimationDuration = 0.3;
@implementation EOCAnimatedView
- (void)animate {
[UIView animateWithDuration:kAnimationDuration
animations:^(){
// Perform animations
}];
}
@end

使用static const声明常量是重要的。使用const修饰符代表如果你试图在之后修改值,编译器将会报错。在这种情况下,这种做法是必需的。这个值不应该被改变。static修饰符的意思是其作用范围在定义它的编译单元内。编译器将其接收到的内容编译成一个目标文件,这个目标文件就是编译单元。在Objective-C中,每一个编译单元就是每一个实现文件。所以在前面的例子中,kAnimationDuration将被声明在EOCAnimatedView.m生成的目标文件中。如果变量没有使用static修饰,编译器将会创建一个外部符号给它。如果另一个编译单元也声明了一个相同名字的变量,将会抛出一个类似的错误:

1
2
3
duplicate symbol _ kAnimationDuration in:
EOCAnimatedView.o
EOCOtherView.o

实际上,当使用staticconst声明一个变量时,编译器最终不会创建一个符号,而是使用存在的值去替代变量,就像宏定义一样。谨记,无论如何,显示类型信息的做法是更好的。

有时,你会想给外部暴漏一个常量。例如,如果你的类使用NSNotificationCenter通知别的类,你可能想这样做。这个功能是一个对象发送通知,另一个对象注册并接收它。通知有一个字符串做名字,并且你可能将其声明为一个外部可见的常量。这样做的意思是可以让任何一个想去注册接收通知的类不需要知道实际的字符名字,而仅使用这个常量。

这种常量需要出现在全局的符号表中,从而可以在声明它之外的编译单元使用。因此,这些常量需要声明在不同与static const示例的地方。这个变量应该像这样被声明:

1
2
3
4
5
// In the header file
extern NSString *const EOCStringConstant;
// In the implementation file
NSString *const EOCStringConstant = @"VALUE";

这个常量声明在头文件,定义在实现文件。在常量类型中,const修饰符是非常重要的。这些声明是从后向前读的,意思是在这种情况下,EOCStringConstant是一个常量指向一个字符串。这正是我们想要的;常量不允许修改所指向的字符串对象。

当编译器在文件中发现一个extern修饰的常量被使用时,extern关键字会告诉编译器,在导入的类中去寻找。这个关键字告诉编译器在全局符号表中有一个EOCStringConstant的符号。这意味着编译器可以不知道常量的定义而去使用它。编译器简单的知道当文件链接为二进制文件时常量是存在的。

常量必须被定义并且仅定义一次。它通常定义在实现文件,声明在它的头文件。编译器将会为从实现文件生成的目标文件的数据段中的字符串分配存储空间。无论它在哪里被使用,当这个目标文件跟其他文件链接生成最后的二进制时,链接器都将能找到它的全局符号。

实际上,符号出现在全局符号表的意思是你应该小心常量的命名。例如,一个处理登录的类会在登录后对整个应用发送一个通知。这个通知看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// EOCLoginManager.h
#import <Foundation/Foundation.h>
extern NSString *const EOCLoginManagerDidLoginNotification;
@interface EOCLoginManager : NSObject
- (void)login;
@end
// EOCLoginManager.m
#import "EOCLoginManager.h"
NSString *const EOCLoginManagerDidLoginNotification =
@"EOCLoginManagerDidLoginNotification";
@implementation EOCLoginManager
- (void)login {
// Perfoem login asynchronously, then call 'p_didLogin'.
}
- (void)p_didLogin {
[[NSNotificationCenter defaultCenter]
postNotificationName:EOCLoginManagerDidLoginNotification
object:nil];
}
@end

注意常量的名字。常量前缀使用类的名字是谨慎的并且可以帮助你去避免冲突。这在整个系统框架中都是常见的。UIKit,例如,将通知名称以相同的办法声明为全局常量。这些名字包括UIApplicationDidEnterBackgroundNotificationUIApplicationWillEnterForegroundNotification.

其它类型的常量同样可以这样做。如果动画持续时间需要暴露在EOCAnimatiedView类之外,在上面的例子中,你可以这样声明:

1
2
3
4
5
//EOCAnimatedView.h
extern const NSTimeInterval kAnimationDuration;
//EOCAnimatedView.m
const NSTimeInterval kAnimationDuration = 0.3;

用这种方法定义一个常量是优于使用宏定义的,因为编译器可以确定这个值不能被改变。在EOCAnimatedView.m定义一次,它的值可以在任何地方使用。一个宏定义可以重复定义,意味着一个程序的不同部分可能使用了不同的值。

综上所述,对于常量避免使用宏定义。相反,使用编译器可以观察的常量,如在实现文件中使用staticconst声明常量。

小结

  • 避免宏定义。它们不包含任何类型信息并且可以在编译前被简单的查找替换。它们可以重新定义并且没有警告,使得整个应用的值不一致。
  • 使用staticconst在实现文件中定义特定编译单元的常量。这些常量将不会被暴露在全局字符表中,所以它们的名字不需要命名空间。
  • 使用extern在头文件声明它们,在关联的实现文件定义它们。这些常量将出现在全局字符表,所以它们的名字应该需要命名空间,通常使用类名作为前缀是合理的。

使用枚举表示状态、选项、状态码

因为Objective-C是基于C的,C的所有功能Objective-C都是适用的。其中一个就是枚举类型,enum。它在系统框架中被广泛使用,但是常常被开发者忽略。它用于定义命名常量是非常有用的,例如,错误状态码和定义选项是可以组合的。感谢C++11标准支持了它,最近的系统版本包含枚举类型。是的,Objective-C也得益于C++11的标准。

枚举仅仅是命名常量的一种方法。一个简单的枚举集合可以用来表示一个对象的状态。例如,一个socket链接使用枚举表示如下:

1
2
3
4
5
enum EOCConnectionState {
EOCConnectionStateDisconnected,
EOCConnectionStateConnecting,
EOCConnectionStateConnected,
};

使用一个枚举意味着代码是可读的,因为每一个状态可以通过一个易读的值去代表。在枚举中编译器给每个成员一个唯一的值,从0开始每个成员加1。这种枚举是依靠编译器支持的但需要足够的位数去表示枚举。在前面的枚举中,仅需要1个字节就够了,因为它的最大值是2。

然而定义枚举变量的方式却不太简洁,需要使用下面的语法:

1
enum EOCConnectionState state = EOCConnectionStateDisconnected;

如果每次不用使用enum只使用EOCConnectionState就好了。为了这样做,你添加一个typedef给枚举定义:

1
2
3
4
5
6
enum EOCConnectionState {
EOCConnectionStateDisconnected,
EOCConnectionStateConnecting,
EOCConnectionStateConnected,
};
typedef enum EOCConnectionState EOCConnectionState;

它的意思是使用EOCConnectionState代替enum EOCConnectionState

1
EOCConnectionState state = EOCConnectionStateDisconnected;

C++11标准的问世给枚举带来了一些变化。其中一个变化是可以决定使用哪种“数据类型”去存储枚举类型。这样做的好处是可以使用向前声明了。如果不指定数据类型,枚举类型无法使用向前声明,因为编译器无法知道数据类型的大小。因此,当用到枚举类型时,编译器无法知道应该给变量分配多大的空间。

给枚举指定数据类型,你可以使用下面这种语法:

1
enum EOCConnectionStateConnectionState : NSInteger { /*...*/};

上面代码的意思是保证枚举的变量类型是NSInteger。如果你乐意,这种类型的向前声明可以写成这样:

1
enum EOCConnectionStateConnectionState : NSInteger;

你可以给枚举成员定义一个确切的值,而不是让编译器帮你指定。这语法看起来是这样的:

1
2
3
4
5
enum EOCConnectionStateConnectionState {
EOCConnectionStateDisconnected = 1,
EOCConnectionStateConnecting,
EOCConnectionStateConnected,
};

这意思是EOCConnectionStateDisconnected的值为1而不是0.别的成员变量的值同之前一样,依次递增1。例如,EOCConnectionStateConnected的值就是3。

另一种使用枚举类型的情况是去定义选项,特指当选项可以组合在一起时。只要当各选项定义的对,那么就可以使用“按位或运算符”去组合它们。例如,考虑下面的枚举类型,在iOSUI框架中,用来表示某个视图如何调整大小:

1
2
3
4
5
6
7
8
9
enum UIViewAutoresizing {
UIViewAutoresizingNone = 0,
UIViewAutoresizingFlexibleLeftMargin = 1 << 0,
UIViewAutoresizingFlexibleWidth = 1 << 1,
UIViewAutoresizingFlexibleRightMargin = 1 << 2,
UIViewAutoresizingFlexibleTopMargin = 1 << 3,
UIViewAutoresizingFlexibleHeight = 1 << 4,
UIViewAutoresizingFlexibleBottomMargin = 1 << 5,
};

每一个选项都可以开启或者关闭,使用上面的语法可以控制它因为每个选项都仅有单一的一位值去代表它自身。使用“按位或操作”可以组合多个选项。例如:UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight。图1.2展示了每个枚举成员和组合两个成员后的位布局。

通过“按位与操作”是可以判断出某个选项是否启用:

1
2
3
4
5
6
enum UIViewAutoresizing resizing =
UIViewAutoresizingFlexibleWidth |
UIViewAutoresizingFlexibleHeight;
if (resizing & UIViewAutoresizingFlexibleWidth) {
// UIViewAutoresizingFlexibleWidth is set
}

图1.2每个选项值的二进制表示形式,以及两个选项值运用“按位或运算”之后的二进制形式。

枚举在系统框架中使用非常广泛。另一个例子是iOS中的UIKit框架中的,用枚举值列举视图所支持的方向并告诉系统。它使用一个叫做UIInterfaceOrientationMask的枚举类型来实现,并且你可以实现一个叫做supportedInterfaceOrientations的方法去告诉系统视图所支持的方向:

1
2
3
4
- (NSUInteger)supportedInterfaceOrientations {
return UIInterfaceOrientationMaskPortrait |
UIInterfaceOrientationMaskLandscapeLeft;
}

Foundation框架中有一些辅助宏定义,用这些宏定义可以指定枚举值的数据类型。它们提供了向后兼容性,当编译器支持新的语法特性时,它们使用新的语法;当编译器只支持旧语法特性时,它们使用旧的语法特性。这些宏是由#define预处理指令定义的。一个支持像EOCConnectionState这种普通类型的枚举,另一个支持像UIViewAutoresizing这种一系列选项的枚举。你可以像下面这样使用它们:

1
2
3
4
5
6
7
8
9
10
11
typedef NS_ENUM(NSUInteger, EOCConnectionState) {
EOCConnectionStateDisconnected,
EOCConnectionStateConnecting,
EOCConnectionStateConnected,
};
typedef NS_OPTIONS(NSUInteger, EOCPermittedDirection) {
EOCPermittedDirectionUp = 1 << 0,
EOCPermittedDirectionDown = 1 << 1,
EOCPermittedDirectionLeft = 1 << 2,
EOCPermittedDirectionRight = 1 << 3,
};

这些宏的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#if (__cplusplus && __cplusplus >= 201103L&&
(__has_extension(cxx_strong_enums) ||
__has_feature(objc_fixed_enum))
) ||
(!__cplusplus && __has_feature(objc_fixed_enum))
#define NS_ENUM(_type, _name)
enum _name : _type _name; enum _name : _type
#if (__cplusplus)
#define NS_OPTIONS(_type, _name)
type _name; enum : _type
#else
#define NS_OPTIONS(_type, _name)
enum _name : _type _name; enum _name : _type
#endif
#else
#define NS_ENUM(_type, _name) _type _name; enum
#define NS_OPTIONS(_type, _name) _type _name; enum
#endif

由于要处理不同的情况所以要用多种方式去定义两个宏。第一个判断是检查编译器是否支持新式枚举的特性。这个布尔逻辑看起来相当复杂,但它的意思就是判断编译器是否支持新特性。如果不支持,那么就用老的方式。

如果新特性是可用的,那么NS_ENUM宏所定义的类型展开后是这样的:

1
2
3
4
5
6
typedef enum EOCConnectionState : NSUInteger EOCConnectionState;
enum EOCConnectionState : NSUInteger {
EOCConnectionStateDisconnected,
EOCConnectionStateConnecting,
EOCConnectionStateConnected,
};

NS_OPTIONS宏的展开是取决于编译形式的。如果以C++形式编译,那么NS_OPTIONS宏展开与NS_ENUM宏展开是不一样的,否则是一样的。为什么?因为C++环境下两个枚举值通过按位或运算结果与非C++环境下是不同的。前面已经提到了,作为选项的枚举值经常会通过按位或运算进行组合。当两个值通过按位或运算后,C++认为它们所代表的值类型是NSUInteger。并且也不允许饮食转换为枚举类型。为了说明这个,我们考虑将EOCPermittedDirectionNS_ENUM形式展开:

1
2
3
4
5
6
7
typedef enum EOCPermittedDirection : int EOCPermittedDirection;
enum EOCPermittedDirection : int {
EOCPermittedDirectionUp = 1 << 0,
EOCPermittedDirectionDown = 1 << 1,
EOCPermittedDirectionLeft = 1 << 2,
EOCPermittedDirectionRight = 1 << 3,
};

考虑下面的代码:

1
2
EOCPermittedDirection permittedDirections =
EOCPermittedDirectionLeft | EOCPermittedDirectionUp;

当在C++或者Objective-C++环境下,将会出现下面的错误:

1
2
error: cannot initialize a variable of type
'EOCPermittedDirection' with an rvalue of type 'int'

你必须将通过按位或操作的结果显示的转化为EOCPermittedDirection。所以,在C++的环境下应该使用NS_OPTIONS,省去显示转化这一步。因此,如果需要按位或操作的枚举值应当尽量使用NS_OPTIONS,而不需要的,则使用NS_ENUM

枚举可以用在很多情况下。选项和状态已经在前面讲过了;然后更多的情况同样适用。对错误使用状态码是一个好的习惯。把逻辑含义相似的代码放入一个枚举中,用于替代使用预定义和常量。另一个好的地方是样式。例如,你有一个UI元素可以创建不同的样式,一个枚举集合可以完美的表示每个样式。

最后一点是关于在switch中使用枚举变量。又是,你会这样定义它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef NS_ENUM(NSUInteger, EOCConnectionState) {
EOCConnectionStateDisconnected,
EOCConnectionStateConnecting,
EOCConnectionStateConnected,
};
switch (_currentState) {
EOCConnectionStateDisconnected:
// Handle disconnected state
break;
EOCConnectionStateConnecting:
// Handle connecting state
break;
EOCConnectionStateConnected:
// Handle connected state
break;
}

我们习惯在switch语句中加上默认分支。然而,当使用枚举值表示状态时,最好不要写一个默认分支。这样做的原因是,当你在稍后添加了一个新的状态,那么编译器将会警告你,提示你有新的状态未加入switch分支。如果有一个默认的分支,编译器将会处理它,那么编译器将不会发出警告。这个问题同样适用于别的使用NS_ENUM的定义中。例如,你定义一个UI元素,你将会希望每个情况都有一个确定的样式。

小结

  • 使用枚举来表示状态机的状态,传递给函数的选项,或者错误码,并且起一个易读的名字。
  • 如果把一个传递给方法的选项表示为枚举类型,同时还需要多重操作,可以将它们的值设为2的幂,然后通过按位或操作将其组合起来。
  • 使用NS_ENUMNS_OPTIONS宏最好给枚举定义一个明确的数据类型。这样做的意思是确定枚举的数据类型是开发者设置的,而不是编译器指定的。
  • switch中使用枚举时,不要设置默认分支。这样在你添加新枚举后,编译器会提醒你添加新分支。